iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
佛心分享-IT 人自學之術

從空拍到3D展示系列 第 29 篇

Day 29. 手機輕點沒模型反應: 找出原因,改用 pointer 事件

  • 分享至 

  • xImage
  •  

昨天把模型搬到 Cloudflare R2,網站部署好之後,今天用手機實際玩了一次。

測試環境:

項目 結果
旋轉(單指拖曳) 可以
平移(雙指一起拖曳) 可以,但蠻難移
HUD(計時/彩蛋數字) 有顯示
輕點 沒有反應,計時也沒有啟動

遊戲的玩法是「點到彩蛋就算找到」,輕點沒反應,在手機上就完全不能玩。今天就來處理這個問題:先找出原因,再動手修。


一、先從程式的順序縮小範圍

原本 main.js 處理點擊的程式,開頭是這樣:

window.addEventListener('click', (event) => {
  if (isFinished()) return;
  if (event.target !== renderer.domElement) return;

  startTimer();
  // ……接下來才是射線判斷,看有沒有點到彩蛋
  • window.addEventListener('click', ...):在整個視窗上監聽 click,點到任何地方都會進來。
  • isFinished():遊戲是不是已經結束(彩蛋全部找完)。剛開始玩的時候一定是 false,所以不會在這裡停下來。
  • event.target !== renderer.domElement:event.target 是被點到的元素,renderer.domElement 是 Three.js 畫圖用的 <canvas>(畫布)。點到的不是畫布,例如點到「重新開始」按鈕,就不處理。
  • startTimer():第一次點擊時開始計時。
  • 射線判斷(看有沒有點到彩蛋)排在 startTimer() 後面。

手機輕點時計時沒有啟動,代表程式根本沒有走到 startTimer()。所以問題不是「彩蛋太小、沒點中」,因為那是射線判斷的事,排在更後面。只剩兩種可能:

  1. click 根本沒有觸發。
  2. click 有觸發,但被點到的不是畫布,代表有別的元素蓋在畫布上面。

二、實驗:手機到底收到了哪些事件

為了分清楚是哪一種,我在 main.js 最後暫時加上這段診斷程式:

// 暫時的診斷程式,測完要刪掉
['pointerdown', 'pointerup', 'click'].forEach((type) => {
  window.addEventListener(type, (event) => {
    showToast(`${type}:${event.target.tagName}`);
  }, true);
});
  • ['pointerdown', 'pointerup', 'click']:要觀察的三種事件。pointerdown 是手指(或滑鼠)按下,pointerup 是放開,click 是原本程式在等的點擊。前兩種屬於 Pointer Events。
  • .forEach((type) => { ... }):對三種事件各做一次監聽。
  • window.addEventListener(type, ..., true):第三個參數 true 代表在「捕獲階段」監聽。依照 MDN 的說明,這樣的監聽器會在事件傳到底下任何元素之前就先收到。掛在 window 上,不管點到哪個元素都看得到。
  • event.target.tagName:被點到的元素的標籤名稱,畫布會顯示 CANVAS。
  • showToast(...):之前在 ui.js 寫好的提示訊息,把「事件名稱:元素標籤」顯示在畫面上。手機上不方便開 Console,用畫面提示最直接。

實驗結果

用手機輕點畫布,畫面上出現 pointerdown:CANVAS 和 pointerup:CANVAS,沒有出現 click。

所以:

  • 手指的按下、放開都有送到畫布,沒有東西蓋在畫布上。
  • 但是在這支手機上,瀏覽器沒有送出 click。

三、對照 TrackballControls 的原始碼

遊戲用 TrackballControls 處理旋轉、平移、縮放,它也在監聽畫布上的事件。我去讀了 three.js r160 的 TrackballControls 原始碼,跟觸控有關的是這幾件事:

  • 建立控制器時,把畫布的 style.touchAction 設成 'none',原始碼註解寫的用途是關掉觸控捲動。
  • pointerdown 時,如果這是第一根按下的手指,會對畫布呼叫 setPointerCapture,讓這根手指之後的事件都送到畫布。
  • 只有 wheel(滾輪)和 contextmenu(右鍵選單)會呼叫 preventDefault(),pointerdown、pointerup 都沒有。

從原始碼看不出 TrackballControls 有擋掉 click。手機瀏覽器為什麼沒有送出 click,這次沒有再往下追。既然實驗已經確定 pointerdown 和 pointerup 都收得到,就直接用這兩個事件,自己判斷什麼叫「輕點」。


四、解法:用 pointerdown + pointerup 自己判斷「輕點」

在畫布上,手指會做三種事:

動作 特徵
輕點 一根手指按下又放開,中間幾乎沒有移動
旋轉 一根手指按下後拖曳,移動距離比較長
平移、縮放 兩根手指同時在畫布上

所以「輕點」的條件可以寫成:只有一根手指,而且按下到放開移動不多。

這段判斷跟遊戲規則無關,任何畫布都用得到。專案裡每個檔案各管一件事(loader.js 載入模型、scene.js 場景、timer.js 計時、ui.js 畫面提示),所以輕點判斷也獨立成一個新檔案。

新增 src/tap.js ★

// ★ 判斷「輕點」:按下到放開,同一根手指(或滑鼠左鍵)移動不多才算
const TAP_MAX_MOVE = 10;   // ★ 移動超過 10 像素就當成拖曳

export function onTap(element, callback) {                       // ★
  let tapStart = null;     // ★ 按下的位置與指標編號;null 代表這次不算輕點
  let activePointers = 0;  // ★ 目前有幾根手指(或滑鼠)按在 element 上

  element.addEventListener('pointerdown', (event) => {
    activePointers++;
    if (activePointers === 1 && event.button === 0) {
      tapStart = { x: event.clientX, y: event.clientY, id: event.pointerId };
    } else {
      tapStart = null;     // 第二根手指放上來(雙指操作)或不是左鍵,不算輕點
    }
  });

  element.addEventListener('pointerup', (event) => {
    activePointers = Math.max(0, activePointers - 1);
    if (!tapStart || event.pointerId !== tapStart.id) return;

    const dx = event.clientX - tapStart.x;
    const dy = event.clientY - tapStart.y;
    tapStart = null;

    if (Math.hypot(dx, dy) > TAP_MAX_MOVE) return;   // 移動太多,是拖曳
    callback(event);
  });

  element.addEventListener('pointercancel', () => {
    activePointers = Math.max(0, activePointers - 1);
    tapStart = null;       // 瀏覽器中斷了這次觸控,不算輕點
  });
}

最上面:門檻

  • TAP_MAX_MOVE = 10:按下到放開,移動超過 10 像素就當成拖曳,不算輕點。10 是我自己選的起始值,不是什麼標準:如果常常點了沒反應,可以調大;如果旋轉時常被誤判成點擊,就調小。

onTap(element, callback)

  • export function onTap(element, callback):對外提供的函式。element 是要監聽的元素,callback 是判定為輕點時要執行的函式。用法跟 addEventListener 很像,呼叫的人不需要知道輕點是怎麼判斷的。
  • tapStart:記錄按下時的位置和指標編號。null 代表「這次不算輕點」。
  • activePointers:目前有幾根手指(或滑鼠)按在元素上,用來分辨單指和雙指。
  • 這兩個變數寫在 onTap 裡面,每呼叫一次 onTap 就各有一份,不會互相干擾。

pointerdown:按下時

  • activePointers++:多一根手指按下。
  • activePointers === 1:只有這一根手指,才可能是輕點。
  • event.button === 0:button 是按下的是哪個鍵,0 是主要按鍵,通常是滑鼠左鍵。pointerdown 的事件物件是 PointerEvent,依照 MDN,它繼承了 MouseEvent 的屬性,所以也有 button。觸控時 button 的值 MDN 這頁沒有寫,不過改完後手機輕點有反應,代表在我的手機上,手指按下時這個條件成立。
  • event.button === 0:button 表示是哪個按鍵造成這次事件。依照 MDN,0 是主要按鍵(通常是左鍵)、1 是中鍵或滾輪、2 是右鍵。

pointerdown 的事件物件是 PointerEvent,它繼承了 MouseEvent 的屬性,所以也有 button。Pointer Events 規格的按鍵狀態表裡,手指觸碰螢幕、觸控筆碰到螢幕,button 也都是 0。所以這個條件同時涵蓋「滑鼠左鍵」和「手指」,並把右鍵(2,TrackballControls 用來平移)、中鍵(1)排除在外。

  • event.clientX、event.clientY:按下的位置,以瀏覽器視窗左上角為原點,單位是像素。
  • event.pointerId:每個指標的唯一編號,兩根手指會有不同的編號。放開時用它確認是不是同一根手指。
  • else 的 tapStart = null:第二根手指放上來(變成雙指操作),或按的是右鍵,就把這次作廢。

pointerup:放開時

  • activePointers = Math.max(0, activePointers - 1):少一根手指。Math.max(0, ...) 確保數字不會變成負的。
  • if (!tapStart || event.pointerId !== tapStart.id) return:這次已經作廢,或放開的不是當初按下的那根手指,就不處理。
  • dx、dy:放開時的位置減掉按下時的位置,是水平、垂直各移動了多少像素。
  • tapStart = null:用完就清掉,下一次重新開始記錄。
  • Math.hypot(dx, dy):回傳各參數平方和的平方根,也就是按下點到放開點的直線距離。超過 TAP_MAX_MOVE 就是拖曳,直接結束。
  • callback(event):判定是輕點,執行傳進來的函式,並把 pointerup 的事件物件交給它。

pointercancel:被中斷時

  • pointercancel 是瀏覽器判斷這個指標不會再有後續事件時送出的,例如觸控途中切換 App、螢幕轉向、同時按下太多根手指。
  • 這時一樣把手指數減一,並把這次作廢。

修改 src/main.js

最上面多一行 import:

import { onTap } from './tap.js';                       // ★ 輕點判斷

點擊處理改用 onTap:

onTap(renderer.domElement, (event) => {               // ★ 取代 window 的 click(手機收不到 click)
  if (isFinished()) return;
  // ★ 不再檢查 event.target:onTap 已經直接掛在畫布上

  startTimer();    // ★ 第一次點擊才開始計時(之後再呼叫會直接跳過)

  const pointer = new THREE.Vector2();
  const raycaster = new THREE.Raycaster();

  // 1. 把滑鼠的像素座標,換算成 NDC
  pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
  pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;

  // 2. 從相機、往滑鼠指的方向,射出這條線
  raycaster.setFromCamera(pointer, camera);

  // 3. 問這條線,打中了 scene 裡的哪些東西(true 代表連子物件也要檢查)
  const intersects = raycaster.intersectObjects(scene.children, true);

  if (intersects.length > 0) {
    const hit = intersects[0].object;
    const eggRoot = findEggRoot(hit);

    if (!eggRoot) {
      //console.log('點到公園本體:', hit.name);
      return;
    }

    if (eggRoot.userData.found) {
      console.log('這個彩蛋已經找過了');
      return
    } 

    const result = markEggFound();
      if (!result) return;

      eggRoot.userData.found = true;


     if (result.allFound) {
      showResult(result.seconds)
    } else {
      showToast(`🥚 已找到 ${result.foundEggs} / ${result.totalEggs}`);
    }
  }
});

這次只改了開頭兩行,其餘跟原本完全一樣:

  • onTap(renderer.domElement, (event) => { ... }):取代原本的 window.addEventListener('click', ...)。監聽的對象從整個視窗改成畫布,判定是輕點才執行裡面的程式。
  • 刪掉 if (event.target !== renderer.domElement) return;:onTap 直接掛在畫布上,進來的一定是畫布上的輕點,不需要再檢查。
  • 後面用到的 event.clientX、event.clientY 不用改:傳進來的是 pointerup 的 PointerEvent,它繼承自 MouseEvent,一樣有這兩個屬性。

改完記得把第二節的診斷程式刪掉。

電腦版的滑鼠點擊也改由 onTap 判斷。按下到放開移動超過 10 像素,就算拖曳,不算點擊。


五、測試結果

改完後再用手機測,輕點已經有反應了。電腦版也有測試,沒有因為從監聽事件 click 改成 pointerup/pointerdown 而有不預期的 bug。


六、還可以改進的地方

手機可以玩了,但實際玩起來還有一個問題:手機螢幕小,就算把模型放到最大,還是很難看出彩蛋在哪裡,放大的效果有限。 另外,上面測試表裡的雙指平移也蠻難移。這兩點先記下來,目前接近尾聲。明天打算做一個總整理,列出未來還可以優化的地方,以及我這一系列文的回顧。


上一篇
Day28. 解決模型載入速度神之慢
下一篇
Day30. 最後一天: 遊戲重新開始不用再重新載入模型,以及未來待辦與回顧
系列文
從空拍到3D展示 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言